Skip to content

Add emoji reactions (limited set on notes) - #76767

Open
adamsilverstein wants to merge 213 commits into
trunkfrom
add-notes-emoji-reactions-try-addditional-comment-type
Open

adamsilverstein wants to merge 213 commits into
trunkfrom
add-notes-emoji-reactions-try-addditional-comment-type

Conversation

@adamsilverstein

@adamsilverstein adamsilverstein commented Mar 23, 2026 •

Copy link
Copy Markdown
Member

Summary

Closes #75144

Adds the foundation for emoji reactions on block notes. This PR is scoped to a small curated 5-emoji quick row (heart, celebration, smile, eyes, rocket). The full searchable emoji picker behind a + button lives in a follow-up PR stacked on this one - per @t-hamano's request, so library/bundling decisions can be reviewed separately.

Storage: reactions as a custom comment type

Reactions are stored as individual reaction comment records (one per user-emoji-note triple), as suggested by @swissspidy, in preference to JSON blobs in comment meta:

comment_type    = 'reaction'
comment_parent  = <note ID>
comment_content = <emoji slug>             // e.g. 'heart'
user_id         = <reacting user>

This is more flexible, aligns with WordPress data patterns (similar to the React plugin), and could eventually extend to regular comments. The custom type is hidden from wp-admin/edit-comments.php and excluded from comment counts.

Curated emoji set

The default 5 emojis (heart, celebration, smile, eyes, rocket) are stored as ASCII slugs (e.g. heart). Storing slugs sidesteps utf8 vs utf8mb4 portability issues on the comments table and gives stable grouping in the reaction_summary aggregation.

The list is filterable via the gutenberg_note_reaction_emojis PHP filter and exposed to clients through the REST API schema (reaction_emojis property on OPTIONS /wp/v2/comments), implementing the approach suggested by @Mamaduka.

Efficient reaction loading

Each note response includes a reaction_summary field - { [emojiSlug]: { count, reacted, my_reaction_id } }. The schema lives in class-gutenberg-rest-comment-controller-7-1.php, populated in prepare_item_for_response().

  • No N+1: get_items() pre-fetches summaries for the entire result set with one aggregated GROUP BY query plus one query for the current user's own reactions, regardless of how many notes are returned (see prefetch_reaction_summaries()).
  • Lazy-loaded reactor names: who reacted is not embedded in reaction_summary - names are fetched on tooltip hover, so the initial notes load stays cheap.

Popover architecture

The curated picker uses @wordpress/components/Dropdown, whose Popover portals to <body>. This is essential because the collab sidebar has an overflow: hidden chain reaching all the way up to .interface-interface-skeleton__sidebar (framework-level); a non-portaled popover would clip. The note thread's onBlur handler exempts .components-popover so that focus moving into the picker doesn't deselect the note (which would unmount the trigger and close the picker mid-click).

Where the trigger lives

The 😊 trigger floats in the note's top corner, in the same column as Resolve and the actions menu, and stays faded until the note is hovered or the trigger itself is focused. A note nobody has reacted to costs no vertical space for the option, and the note text keeps a safe area on that side so it never runs underneath. Once a reaction lands the row drops into the flow, and the trigger trails the pills and wraps with them. This follows @annezazu's suggestion and @fcoveram's exploration, and was first built in #82313 on the follow-up PR before moving here per @draganescu's question.

  • The reveal hangs off the note and the trigger, not the thread. The thread is tabIndex={ 0 }, so a thread-level :focus-within would hold for as long as the note is selected, and a thread-level :hover would reveal the trigger on every note in the thread at once.
  • The whole treatment sits inside @media (hover: hover), so a touch device keeps a trigger it can actually see.
  • Pills no longer disappear when a note is deselected. Reactions are information about the note rather than one of its actions.
  • Pill treatment follows the Notes emoji reaction Figma: your own reaction is a transparent pill with the brand outline, other people's are quiet neutral text, and the trigger is a minimal icon button - all Design System Button variants, no custom CSS.

Related

Remaining feedback from #75549

  • Unbounded reaction requests (@ellatrix) - useEntityRecords for reactions has no per_page limit. Consider lazy-loading reactions only when a note thread is opened. Comment
  • Move emoji SVG icons to @wordpress/icons (@ellatrix) - Low priority, fine for now. Comment
  • Reaction embedding (@Mamaduka) - Reactions could be an embeddable resource on each note to reduce the number of requests. Comment
  • Performance at scale (@Mamaduka) - Consider how reactions perform with thousands of comments (e.g. make.wordpress.org). Comment

Already addressed

  • Move PHP code to wordpress-7.1 compat dir (@Mamaduka, @t-hamano) - Done
  • Rename comment type to reaction (@swissspidy) - Done
  • Emoji extensibility / filtering (@Mamaduka, @youknowriad, @aaronjorbin) - gutenberg_note_reaction_emojis filter, surfaced via REST schema
  • Re-add reaction after removing - Fixed both server-side (status='approve' duplicate guard) and client-side (force-delete + parent-note refetch); regression-guarded by E2E test
  • Full picker scope (@t-hamano) - Moved to a follow-up PR stacked on this one

Screenshots

At rest Hovered Picker open
A note with no reactions and no visible trigger Hovering the note reveals the smiley trigger in its top corner The curated five-emoji picker open beside the note
Your reactions and someone else's Deselected
An eyes reaction from another user with a quiet pill and tooltip, next to the current user's heart and rocket in the blue outline, with the trigger trailing them A deselected note keeps its reaction pills and hides the trigger

Screencast

Hover to reveal the trigger, react twice, then deselect the note:

Screencast: hovering a note reveals the add-reaction trigger, picking a heart and a rocket adds pills that the trigger trails, and deselecting the note keeps the pills

Testing

Test in WordPress Playground

For testing with multiple users, use the User Switching Plugin - https://wordpress.org/plugins/user-switching/

  • Create a note on a block
  • Move the pointer away from the sidebar - the 😊 trigger is hidden, and the note is no taller for carrying it
  • Hover the note - the trigger fades in at the note's top right corner, in the same column as Resolve and the actions menu
  • Add a reply, then hover one note in the thread - only that note reveals its trigger
  • Tab to the trigger with the keyboard - it appears as focus lands on it
  • Click 😊 - pick from the curated 5-emoji row; the trigger now trails the pill
  • Move the pointer away from the note - with reactions on it, the trigger stays visible at the end of the pills
  • Pick the same emoji twice - verify reaction count goes up, not a duplicate
  • Remove a reaction by clicking its pill, then add the same emoji back - verify it works (regression for the trash-counts-as-duplicate bug)
  • Add multiple different emojis to the same note
  • Hover a reaction pill - verify the tooltip shows the reactor names
  • Verify your own reaction takes the blue outline, and someone else's stays quiet with no border or fill
  • Click the post title to deselect the note - the pills stay, the trigger goes
  • Verify the popover doesn't get clipped by the pinned "All notes" sidebar's overflow
  • Check wp-admin comments page doesn't show reaction comments
  • Check comment counts aren't inflated by reactions
  • Verify keyboard accessibility: Enter on smiley opens picker, ArrowDown/ArrowUp navigates, Enter selects, Escape closes
  • Run E2E tests: npm run test:e2e -- test/e2e/specs/editor/various/block-notes.spec.js
  • Run PHP tests: npm run test:unit:php:base -- --filter=Gutenberg_REST_Comments

AI Use

Code and description both written with 🤖 Claude Code, with plenty of steering. I will review and test.

@github-actions github-actions Bot added the [Package] Editor /packages/editor label Mar 23, 2026
@adamsilverstein adamsilverstein added [Type] Enhancement A suggestion for improvement. [Feature] Notes Phase 3 of the Gutenberg roadmap around block commenting [Status] In Progress Tracking issues with work in progress and removed [Package] Editor /packages/editor labels Mar 23, 2026
@github-actions github-actions Bot added the [Package] Editor /packages/editor label Mar 23, 2026
@github-actions

github-actions Bot commented Mar 23, 2026 •

Copy link
Copy Markdown

Size Change: +2.71 kB (+0.03%)

Total Size: 7.92 MB

📦 View Changed
Filename Size Change
build/scripts/editor/index.min.js 583 kB +2.22 kB (+0.38%)
build/styles/editor/style-rtl.css 31.7 kB +123 B (+0.39%)
build/styles/editor/style-rtl.min.css 27.2 kB +123 B (+0.45%)
build/styles/editor/style.css 31.8 kB +121 B (+0.38%)
build/styles/editor/style.min.css 27.2 kB +123 B (+0.46%)

compressed-size-action

@github-actions

github-actions Bot commented Mar 23, 2026 •

Copy link
Copy Markdown

Flaky tests detected in 4b59106.
Some tests passed with failed attempts. The failures may not be related to this commit but are still reported for visibility. See the documentation for more information.

🔍 Workflow run URL: https://github.com/WordPress/gutenberg/actions/runs/33358611256
📝 Reported tests:

As a user I expect my navigation to use the colors I selected for it in /test/e2e/specs/editor/blocks/navigation-colors.spec.js, passed after 1 failed attempt.
Error: apiRequestContext.fetch: socket hang up
Call log:
  - → GET http://localhost:8889/wp-json/wp/v2/pages?per_page=100&status=publish%2Cfuture%2Cdraft%2Cpending%2Cprivate%2Ctrash
    - user-agent: Playwright/1.62.1 (x64; ubuntu 24.04) node/20.20 CI/1
    - accept: */*
    - accept-encoding: gzip,deflate,br
    - X-WP-Nonce: 2f3c77dfcc
    - cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_23778236db82f19306f247e20a353a99=admin%7C1788325109%7CVDQVpdmKyGDyJHhldNDkxBbMUjRgNfY7pfKdaQevpAB%7Cf13d914b590c5dcad536a59c54d71fbbb03092cab588316d1ce200012f9c2165; wp-settings-time-1=1788152342

    at RequestUtils.rest (/home/runner/work/gutenberg/gutenberg/packages/e2e-test-utils-playwright/src/request-utils/rest.ts:112:39)
    at RequestUtils.deleteAllPages (/home/runner/work/gutenberg/gutenberg/packages/e2e-test-utils-playwright/src/request-utils/pages.ts:44:27)
    at /home/runner/work/gutenberg/gutenberg/test/e2e/specs/editor/blocks/navigation-colors.spec.js:38:17

@swissspidy

Copy link
Copy Markdown
Member

Regarding unbounded requests & embedding reactions:

If embedding all the reactions on a given note is too much, we could think about caching/retrieving only the reaction count (grouped by emoji). This way we can quickly display the different reactions on a note and lazy load the individual reactions (and who gave them) after that.

@github-actions github-actions Bot added the [Package] Icons /packages/icons label Apr 4, 2026
@adamsilverstein adamsilverstein changed the title Notes: Store emoji reactions as custom comment type Notes: Add emoji reactions - stored as custom comment type Apr 29, 2026
@adamsilverstein

adamsilverstein commented Apr 29, 2026 •

Copy link
Copy Markdown
Member Author

Two open threads carried over from #75549 worth noting:

  1. Notes query is still unbounded (hooks.js:39 uses per_page: -1). reaction_summary solved the per-reaction fan-out, but Mamaduka's scale concern about thousands of notes (e.g. make.wordpress.org blogs) is still open. Probably a follow-up rather than a blocker, but flagging.
  2. Storage decision (custom comment type vs. meta) was settled across #75148 and #75549 but isn't documented here. Linking the rationale in the PR description would save a future reviewer from relitigating it.

All other feedback from #75549 has been addressed — see my comparison for details.

@adamsilverstein
adamsilverstein requested review from Mamaduka and t-hamano and removed request for spacedmonkey April 29, 2026 03:01
@adamsilverstein
adamsilverstein marked this pull request as draft April 29, 2026 03:01
@adamsilverstein

adamsilverstein commented Apr 29, 2026 •

Copy link
Copy Markdown
Member Author

@swissspidy this is what's shipping now - in case you want to double-check it matches what you had in mind:

  • Counts grouped by emoji: each note response includes a reaction_summary field — { [emojiSlug]: { count, reacted, my_reaction_id } }. Schema in class-gutenberg-rest-comment-controller-7-1.php:54, populated in prepare_item_for_response().
  • No N+1: get_items() pre-fetches summaries for the entire result set with one aggregated GROUP BY query plus one query for the current user's own reactions, regardless of how many notes are returned (see prefetch_reaction_summaries()).
  • Lazy-loaded reactor names: who reacted is not in reaction_summary — names are fetched on tooltip hover (commit 776efb9bc00), so the initial notes load stays cheap.

The unbounded per_page: -1 on the notes query itself is still open — that's a separate concern about how many notes we load at once, not how reactions are loaded per note.

@adamsilverstein

Copy link
Copy Markdown
Member Author

I'm going to look at leveraging an existing library for the emoji picker itself, something like https://github.com/liveblocks/frimousse that is well maintained, license compatible and designed to be styled/extended. This would provide a much richer experience, and we can still start with the simplified design of a small set of emoji with the full selection "behind" a (+) plus button.

@adamsilverstein

Copy link
Copy Markdown
Member Author

Here is a screencast of the current implementation in action:

react.with.emoji.picker.mp4

@adamsilverstein
adamsilverstein marked this pull request as ready for review May 8, 2026 00:13
@adamsilverstein
adamsilverstein requested review from aaronjorbin and removed request for ajitbohra, nerrad and ntwb May 8, 2026 00:13
adamsilverstein and others added 2 commits October 12, 2026 17:07
The excluded comment types are string literals, so they can go into the
SQL directly instead of through placeholders. Type the hook callbacks to
what core passes them, which also lets the WP_Comment guards go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0126YH7wfjdvbPpuuNv1v92h
Co-authored-by: Weston Ruter <westonruter@gmail.com>
@t-hamano

Copy link
Copy Markdown
Contributor

@westonruter I've resolved the comments regarding the existing core code. It might be best to focus the review only on the parts actually being changed in this PR.

@Mamaduka

Copy link
Copy Markdown
Member

It might be best to focus the review only on the parts actually being changed in this PR.

Unfortunately, the way we have to override the controller makes that process a bit harder.

Comment on lines +409 to +425
// START CHANGE: Reject a reaction on behalf of another user.
/*
* A reaction is always first-person: the uniqueness check and the
* reaction summary key on the current user, so one stored against
* somebody else could never be counted or removed.
*/
if (
'reaction' === $request['type'] &&
isset( $request['author'] ) && get_current_user_id() !== (int) $request['author']
) {
return new WP_Error(
'rest_comment_invalid_author',
__( 'Sorry, you are not allowed to add a reaction on behalf of another user.', 'gutenberg' ),
array( 'status' => rest_authorization_required_code() )
);
}
// END CHANGE.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Users with the moderate_comments capability can create comments and notes on behalf of other users, so at first I wasn't sure we needed a special check only for reactions. That said, a user can react with the same emoji only once per note, and the uniqueness check keys on the current user, so this check might make sense.

@Mamaduka @westonruter What do you think?

Comment thread lib/compat/wordpress-7.2/note-reactions.php Outdated
Comment thread lib/compat/wordpress-7.2/class-gutenberg-rest-comment-controller-7-2.php Outdated
Comment thread lib/compat/wordpress-7.2/class-gutenberg-rest-comment-controller-7-2.php Outdated
Comment on lines +103 to +122
function gutenberg_filter_comment_count_query_exclude_block_comments_7_2( $query ) {
if ( str_starts_with( $query, 'SELECT comment_post_ID, COUNT(comment_ID) as num_comments FROM' ) && str_contains( $query, 'comment_approved' ) ) {
// Add an exclusion clause for each internal type not already present.
// Core's query already hardcodes the note-only exclusion, so expanding
// per type - rather than bailing when any exclusion exists - ensures
// reactions are excluded too and keeps the filter idempotent if it
// runs more than once.
$type_clauses = array();
foreach ( array( 'note', 'reaction' ) as $internal_type ) {
$clause = "comment_type != '" . esc_sql( $internal_type ) . "'";
if ( ! str_contains( $query, $clause ) ) {
$type_clauses[] = $clause;
}
}
if ( ! empty( $type_clauses ) ) {
$query = str_replace( 'comment_approved', implode( ' AND ', $type_clauses ) . ' AND comment_approved', $query );
}
}
return $query;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since reactions are always created as approved and can't be updated, they should never be counted in this pending comments query (comment_approved = '0'). Could we just remove this filter?

Comment thread lib/compat/wordpress-7.2/note-reactions.php Outdated
@Mamaduka

Copy link
Copy Markdown
Member

The editor code tests well. It looks like only the remaining items are for the REST API controller.

Should we aim to land this before the end of this week, so the feature is ready for the next Gutenberg RC on Monday?

@t-hamano

Copy link
Copy Markdown
Contributor

@Mamaduka, I think this PR is basically ready to go, but would you mind if I pushed some changes to address the server-side feedback left by @westonruter and me?

@Mamaduka

Copy link
Copy Markdown
Member

@t-hamano, please do that; it would be very helpful. Thank you!

t-hamano and others added 16 commits October 14, 2026 20:22
… input

Co-Authored-By: Claude <noreply@anthropic.com>
…d create input"

This reverts commit 85f63c1.

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Match core, which defines get_note_reaction_keys() as a private static
method on WP_REST_Comments_Controller.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
…om core

Co-Authored-By: Claude <noreply@anthropic.com>
@t-hamano

Copy link
Copy Markdown
Contributor

I think most of the feedback has been addressed. Here's a summary of the commits I pushed:

  1. 6e25ef8: Reactions now follow the regular comment permission model, so users who can edit a reaction can delete it. Since r64343, the edit context response includes an action-trash link, so the author-only check returned a 403 despite that link. See #discussion_r4271328897.
  2. fcc280f: Fixed the type check failure from f6eeb0b and used sentenceLabel in the "reacted with" tooltip. bec32c0 updates the e2e tests for the capitalized labels.
  3. 59677a6: Before WordPress 7.1, get_children() cached count results (fixed in r62822), so prepare_links() now calls get_comments() directly.
  4. 2b7075e: Marked every change from WordPress 7.1 in the comment controller, including new methods, with START CHANGE / END CHANGE.

Here's a summary of the remaining open comments:

  1. #discussion_r4226997739 and #discussion_r4227000380: These point out that the query filters are brittle. There may be room for improvement, although neither function has a dedicated filter, and the core PR changes their SQL directly.
  2. #discussion_r4256586449: This asks about the check that prevents creating a reaction on behalf of another user. Strictly following the comment permission model, it could be removed, but the uniqueness check and reaction_summary both key on the current user.

Finally, note that these changes haven't been backported to the core PR (#10930) yet. If I have time tomorrow, I plan to backport them to core.

I think this is ready for a final round of feedback!

@Mamaduka Mamaduka left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you, @t-hamano!

Going to pre-approve this. We often sync changes back from core backport PRs, and I think it's much easier to review PHP code (fewer lines, etc.).

@westonruter, what do you think?

@adamsilverstein

Copy link
Copy Markdown
Member Author

Thanks @t-hamano for pushing all the updates here, appreciate the extra effort here to get this over the finish line before Beta 1. 🎉

These point out that the query filters are brittle. There may be room for improvement, although neither function has a dedicated filter, and the core PR changes their SQL directly.

I will look at this again, but don't think it should be a blocker to merging. The core side will be a little cleaner, but still messy because of the lack of a formal API.

@t-hamano

Copy link
Copy Markdown
Contributor

@adamsilverstein, I have just finished syncing the updated PHP code from this PR into the core PR. I believe both PRs are now ready to go. As long as the server-side implementation around comment permissions and security is robust, we should be able to address the client-side logic and UI after Beta 1.

@Mamaduka

Copy link
Copy Markdown
Member

As long as the server-side implementation around comment permissions and security is robust, we should be able to address the client-side logic and UI after Beta 1.

We can address most paper cuts after in beta cycle; features usually get more testing once they're merged.

@adamsilverstein

adamsilverstein commented Oct 15, 2026 •

Copy link
Copy Markdown
Member Author

I have just finished syncing the updated PHP code from this PR into the core PR. I believe both PRs are now ready to go. As long as the server-side implementation around comment permissions and security is robust, we should be able to address the client-side logic and UI after Beta 1.

Excellent! I'll give it a review pass.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[Feature] Notes Phase 3 of the Gutenberg roadmap around block commenting [Package] Editor /packages/editor [Status] In Progress Tracking issues with work in progress [Type] Enhancement A suggestion for improvement.

Projects

Status: 🔎 Needs Review

Development

Successfully merging this pull request may close these issues.

Add emoji reactions as a first class comment type